
Unity支持游戏物体投射阴影到其它物体或者在自身的表面上,在画面上显示阴影效果,来提升场景的纵深感和真实感。
确定产生阴影区域的方法就是把光源想象成一个摄像机(暂时叫做光源相机),光源相机的位置和朝向就是光源的位置和发射光线的方向。在渲染场景之前先用光源相机对场景执行一次取景操作,使用LightMode标签为ShadowCaster的Pass,把在光源相机所在角度所有可视的片元深度信息存储在一个帧缓冲区中,称为阴影贴图(Shadow Map),其本质是一张深度图。在真正渲染时把每一个待输出片元再次放到光源相机的角度下计算深度值,如果这次计算的深度值比阴影贴图的深度值要离光源相机远,就表示它落在某个阴影区域中了。Unity就是使用的这种技术。
4.1.1 设置阴影
1. 在渲染阴影之前,我们需要配置一些属性,比如渲染阴影的最大距离和阴影贴图的大小。如果把摄像机看到的物体全部进行阴影的绘制,那么性能消耗极大,且阴影贴图也需要很大的尺寸。所以我们设置一个最大距离100,阴影贴图的大小设置一组枚举,尺寸自己进行选取。先创建Settings子文件夹,新建ShadowSettings脚本,且该类设置为可序列化,然后定义这两个阴影属性。
using UnityEngine;
//阴影属性设置
[System.Serializable]
public class ShadowSettings
{
//阴影最大距离
[Min(0f)]
public float maxDistance = 100f;
//阴影贴图大小
public enum TextureSize
{
_256 = 256, _512 = 512, _1024 = 1024,
_2048 = 2048, _4096 = 4096, _8192 = 8192
}
}
2. 现在只是处理平行光,后续还会支持其它光源类型,所以每种光源应该有自己的阴影设置。定义一个Directional结构体,添加一个阴影贴图大小的字段,默认尺寸为1024,我们将使用单个纹理包含多个阴影贴图,所以字段命名为atlasSize。
//方向光的阴影配置
[System.Serializable]
public struct Directional
{
public TextureSize atlasSize;
}
//默认尺寸为1024
public Directional directional = new Directional
{
atlasSize = TextureSize._1024
};
3. 将阴影配置字段添加到CustomRenderPipelineAsset脚本中,并在创建渲染管线实例时作为参数传入,再在CustomRenderPipeline脚本中定义一个字段跟踪该配置。
//阴影设置
[SerializeField]
ShadowSettings shadows = default;
protected override RenderPipeline CreatePipeline()
{
return new CustomRenderPipeline(useDynamicBatching, useGPUInstancing, useSRPBatcher, shadows);
}
ShadowSettings shadowSettings;
public CustomRenderPipeline(bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher, ShadowSettings shadowSettings)
{
this.shadowSettings = shadowSettings;
...
}

4. 在CustomRenderPipeline.Render方法中调用每个相机的Render方法时把阴影配置作为参数传入。
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
foreach (Camera camera in cameras)
{
renderer.Render(context, camera, useDynamicBatching, useGPUInstancing, shadowSettings);
}
}
5. 给CameraRenderer.Render方法添加一个传入参数ShadowSettings。在调用Cull方法时把阴影最大距离作为参数传入。在Culling方法中,通过传入的阴影最大距离和相机的远截面进行比较,将小的那个作为渲染管线的最大阴影距离,然后Render方法调用lighting.Setup()时也将阴影配置作为参数传递,后续会进行处理。
public void Render (
ScriptableRenderContext context, Camera camera,
bool useDynamicBatching, bool useGPUInstancing,
ShadowSettings shadowSettings
)
{
…
if (!Cull(shadowSettings.maxDistance))
{
return;
}
Setup();
//光源数据和阴影数据发送到GPU计算光照
lighting.Setup(context, cullingResults, shadowSettings);
…
}
bool Cull (float maxShadowDistance)
{
if (camera.TryGetCullingParameters(out ScriptableCullingParameters p))
{
//得到最大阴影距离,和相机远截面作比较,取最小的那个作为阴影距离
p.shadowDistance = Mathf.Min(maxShadowDistance, camera.farClipPlane);
cullingResults = context.Cull(ref p);
return true;
}
return false;
}
6. 在Lighting脚本的Setup方法中加上阴影配置的传参,先不做处理。
public void Setup(ScriptableRenderContext context, CullingResults cullingResults,ShadowSettings shadowSettings)
{
...
}
4.1.2 创建阴影类
阴影虽然是光照的一部分,但是内部结构相当复杂。我们把所有阴影的处理逻辑抽离到一个单独的脚本中去,在Runtime文件夹下创建Shadows.cs脚本,它的基础架构和Lighting.cs脚本差不多,我们复制一下代码并做一些调整。
using UnityEngine;
using UnityEngine.Rendering;
public class Shadows
{
const string bufferName = "Shadows";
CommandBuffer buffer = new CommandBuffer
{
name = bufferName
};
ScriptableRenderContext context;
CullingResults cullingResults;
ShadowSettings settings;
public void Setup(
ScriptableRenderContext context, CullingResults cullingResults,
ShadowSettings settings
)
{
this.context = context;
this.cullingResults = cullingResults;
this.settings = settings;
}
void ExecuteBuffer()
{
context.ExecuteCommandBuffer(buffer);
buffer.Clear();
}
}
在Lighting脚本中我们创建一个Shadows对象,在Setup方法中调用该对象的Setup方法,并传递相关数据和配置。
Shadows shadows = new Shadows();
public void Setup (…)
{
this.cullingResults = cullingResults;
buffer.BeginSample(bufferName);
//传递阴影数据
shadows.Setup(context, cullingResults, shadowSettings);
…
}
4.1.3 带阴影的光源
1. 由于渲染阴影也需要额外的开销,我们在Shadows脚本定义一个字段来限制渲染管线中可以投影的定向光源数量,最初设置为1个。因为不知道哪个可见光源会产生阴影,所以定义一个ShadowedDirectionalLight结构体来追踪可见光的索引,然后创建一个该结构体类型的数组,存储所有能产生阴影的可见光索引。
//可投射阴影的定向光数量
const int maxShadowedDirectionalLightCount = 1;
struct ShadowedDirectionalLight
{
public int visibleLightIndex;
}
//存储可投射阴影的可见光源的索引
ShadowedDirectionalLight[] ShadowedDirectionalLights = new ShadowedDirectionalLight[maxShadowedDirectionalLightCount];
2. 定义一个int字段来追踪当前已经存储了多少个可见光的阴影数据,并在Setup方法中重置该数量。
//已存储的可投射阴影的平行光数量
int ShadowedDirectionalLightCount;
public void Setup (…)
{
…
ShadowedDirectionalLightCount = 0;
}
3. 定义ReserveDirectionalShadows方法来存储可投影可见光的阴影数据,目的是在阴影图集中为该光源的阴影贴图保留空间,并存储渲染它们所需要的信息。其中我们还需要加上一些判断,来过滤不符合要求或没有意义的可见光源。
//存储可见光的阴影数据
public void ReserveDirectionalShadows(Light light, int visibleLightIndex)
{
//存储可见光源的索引,前提是光源开启了阴影投射并且阴影强度不能为0
if (ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount && light.shadows != LightShadows.None && light.shadowStrength > 0f
//还需要加上一个判断,是否在阴影最大投射距离内,有被该光源影响且需要投影的物体存在,如果没有就不需要渲染该光源的阴影贴图了
&&
cullingResults.GetShadowCasterBounds(visibleLightIndex,out Bounds b))
{
ShadowedDirectionalLights[ShadowedDirectionalLightCount++] = new ShadowedDirectionalLight{ visibleLightIndex = visibleLightIndex };
}
}
4. 最后在Lighting.SetupDirectionalLight方法中存储该可见光源的阴影数据。
void SetupDirectionalLight(int index, ref VisibleLight visibleLight)
{
...
shadows.ReserveDirectionalShadows(visibleLight.light, index);
}
4.1.4 创建阴影图集
1. 在Shadows脚本中定义Render方法渲染阴影,渲染定向光阴影我们委派给RenderDirectionalShadows方法去做,然后在Lighting.Setup()方法中发送多个光源数据,最后再调用shadows.Render()。
//阴影渲染
public void Render()
{
if (ShadowedDirectionalLightCount > 0)
{
RenderDirectionalShadows();
}
}
//渲染定向光阴影
void RenderDirectionalShadows()
{
}
public void Setup(ScriptableRenderContext context, CullingResults cullingResults,ShadowSettings shadowSettings)
{
...
//发送光源数据
SetupLights();
shadows.Render();
...
}
2. 现在要创建一张阴影图集,后面会将可以投影的物体绘制到阴影贴图。先定义一个阴影图集的着色器标识ID,在RenderDirectionalShadows方法中创建一张RenderTexture,我们通过添加三个额外的参数(最后三个)来设置阴影图集,深度缓冲使用32位,尽可能的高一点。过滤模式使用双线性过滤即可,渲染纹理的类型指定为Shadowmap,用于指定其为渲染阴影的纹理。
static int dirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas");
//渲染定向光阴影
void RenderDirectionalShadows()
{
//创建renderTexture,并指定该类型是阴影贴图
int atlasSize = (int)settings.directional.atlasSize;
buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize, 32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap);
}
3. 我们应该在相机渲染完后释放临时渲染纹理,在Shadows脚本中添加一个Cleanup方法来完成这个工作。
//释放临时渲染纹理
public void Cleanup()
{
buffer.ReleaseTemporaryRT(dirShadowAtlasId);
ExecuteBuffer();
}
4. 在Lighting脚本中也添加一个Cleanup方法,调用shadows.Cleanup(),然后在CameraRenderer.Render方法提交命令缓冲区之前调用光照的Cleanup方法。
//释放阴影贴图RT内存
public void Cleanup()
{
shadows.Cleanup();
}
public void Render (…)
{
...
lighting.Cleanup();
Submit();
}
5. 创建渲染纹理后调用buffer.SetRenderTarget方法来指定渲染数据存储到渲染纹理而不是帧缓冲区中,该方法中后两个参数用于指定如何加载和存储渲染纹理的数据,它的初始状态我们不关心,因为会立即清除它。渲染纹理的目的是存储阴影数据,所以使用RenderBufferStoreAction.Store模式。最后调用buffer.ClearRenderTarget方法清除渲染目标的数据,这里我们只关心深度缓冲,所以只需清除它。
void RenderDirectionalShadows()
{
int atlasSize = (int)settings.directional.atlasSize;
buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize,32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap);
//指定渲染数据存储到RT中
buffer.SetRenderTarget(dirShadowAtlasId,RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);
//清除深度缓冲区
buffer.ClearRenderTarget(true, false, Color.clear);
ExecuteBuffer();
}
如果当前场景有一个定向光且启用了阴影,加上场景中有可绘制的物体,则在帧调试器中会出现一个Shadows条目的Clear操作。

6. 我们还需要调整阴影渲染的时机,应在相机正式渲染场景之前渲染阴影,所以将CameraRender.Render方法中的Setup方法的调用放在lighting.Setup方法之后。
lighting.Setup(context, cullingResults, shadowSettings);
Setup();
//绘制几何体
DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);

7. 设置在lighting.Setup之前开启采样,然后在清除相机渲染之前结束采样,可以将Shadows条目嵌入到MainCamera条目中,得到我们最终想看到的结果。
buffer.BeginSample(SampleName);
ExecuteBuffer();
lighting.Setup(context, cullingResults, shadowSettings);
buffer.EndSample(SampleName);
Setup();

4.1.5 渲染阴影
1. 接下来开始为单个光源渲染阴影。在Shadows脚本中添加一个重载方法RenderDirectionalShadows(int index,int tileSize),第一个参数是投射阴影的灯光索引,第二个是该光源的阴影贴图在阴影图集中所占的图块大小。在RenderDirectionalShadows方法中遍历所有方向光进行逐光源的阴影渲染,因为现在我们只有一个方向光,所以它的图块大小等于图集大小。
//渲染方向光阴影
void RenderDirectionalShadows()
{
...
//清除深度缓冲区
buffer.ClearRenderTarget(true, false, Color.clear);
buffer.BeginSample(bufferName);
ExecuteBuffer();
//遍历所有方向光渲染阴影
for (int i = 0; i < ShadowedDirectionalLightCount; i++)
{
RenderDirectionalShadows(i, atlasSize);
}
buffer.EndSample(bufferName);
ExecuteBuffer();
}
//渲染单个光源阴影
void RenderDirectionalShadows(int index, int tileSize)
{
}
2. 然后实现该重载方法。要渲染阴影,首先要创建一个ShadowDrawingSettings实例,用来创建阴影设置对象,它需要剔除结果和可见光的索引作为构造时的参数。
//渲染单个光源阴影
void RenderDirectionalShadows(int index, int tileSize)
{
ShadowedDirectionalLight light = ShadowedDirectionalLights[index];
var shadowSettings = new ShadowDrawingSettings(cullingResults, light.visibleLightIndex);
}
3. 阴影贴图本质也是一张深度图,它记录了从光源位置出发,到能看到的场景中距离它最近的表面位置(深度信息)。但是方向光并没有一个真实位置,我们要做地是找出与光的方向匹配的视图和投影矩阵,并给我们一个裁剪空间的立方体,该立方体与包含光源阴影的摄影机的可见区域重叠,这些数据的获取我们不用自己去实现,可以直接调用cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives方法,它需要9个参数。第1个是可见光的索引,第2、3、4个参数用于设置阴影级联数据,后面我们会处理它,第5个参数是阴影贴图的尺寸,第6个参数是阴影近平面偏移,我们先忽略它。最后三个参数都是输出参数,一个是视图矩阵,一个是投影矩阵,一个是ShadowSplitData对象,它描述有关给定阴影分割(如定向级联)的剔除信息。
//渲染单个光源阴影
void RenderDirectionalShadows(int index, int tileSize)
{
...
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(light.visibleLightIndex, 0, 1, Vector3.zero, tileSize, 0f,
out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);
}
4. ShadowSplitData包含了如何剔除投影对象的信息,我们将其复制到阴影设置中,然后调用buffer.SetViewProjectionMatrices方法应用获取的视图和投影矩阵。最后执行缓冲区命令并调用context.DrawShadows方法渲染阴影投射。
//渲染单个光源阴影
void RenderDirectionalShadows(int index, int tileSize)
{
...
shadowSettings.splitData = splitData;
buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);
ExecuteBuffer();
context.DrawShadows(ref shadowSettings);
}
4.1.6 ShadowCaster Pass
1. 现在可以渲染阴影投射了,但阴影图集仍然是空的。 因为DrawShadows方法只渲染Shader中带有ShadowCaster Pass通道的物体,我们在Lit.shader中添加第二个Pass块,可以直接复制第一个Pass块的代码,LightMode改为ShadowCaster,顶点和片元函数改个名,后续投影的实现在后面新建的ShadowCasterPass.hlsl文件里定义。最后,这个Pass只需要写入深度数据,所以添加ColorMask 0不写入任何颜色数据,但会进行深度测试,并将深度值写到深度缓冲区中。
Pass
{
Tags
{
"LightMode" = "ShadowCaster"
}
ColorMask 0
HLSLPROGRAM
#pragma target 3.5
#pragma shader_feature _CLIPPING
#pragma multi_compile_instancing
#pragma vertex ShadowCasterPassVertex
#pragma fragment ShadowCasterPassFragment
#include "ShadowCasterPass.hlsl"
ENDHLSL
}
2. 复制LitPass.hlsl,命名为ShadowCasterPass.hlsl,删除投射阴影不需要的数据,我们只需要顶点在裁剪空间的位置和裁剪的基础颜色,片元函数不需要返回任何值,唯一的作用就是裁剪不满足阈值片元。
#ifndef CUSTOM_SHADOW_CASTER_PASS_INCLUDED
#define CUSTOM_SHADOW_CASTER_PASS_INCLUDED
#include "../ShaderLibrary/Common.hlsl"
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
//提供纹理的缩放和平移
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseMap_ST)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
//用作顶点函数的输入参数
struct Attributes
{
float3 positionOS : POSITION;
float2 baseUV : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
//用作片元函数的输入参数
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 baseUV : VAR_BASE_UV;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
//顶点函数
Varyings ShadowCasterPassVertex(Attributes input)
{
Varyings output;
UNITY_SETUP_INSTANCE_ID(input);
//使UnlitPassVertex输出位置和索引,并复制索引
UNITY_TRANSFER_INSTANCE_ID(input, output);
float3 positionWS = TransformObjectToWorld(input.positionOS);
output.positionCS = TransformWorldToHClip(positionWS);
//计算缩放和偏移后的UV坐标
float4 baseST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST);
output.baseUV = input.baseUV * baseST.xy + baseST.zw;
return output;
}
//片元函数
void ShadowCasterPassFragment(Varyings input)
{
UNITY_SETUP_INSTANCE_ID(input);
float4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.baseUV);
// 通过UNITY_ACCESS_INSTANCED_PROP访问material属性
float4 baseColor = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
float4 base = baseMap * baseColor;
#if defined(_CLIPPING)
//透明度低于阈值的片元进行舍弃
clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
#endif
}
#endif
3. 下面创建一个测试小场景,添加几个不透明的物体,然后使用默认的方向光源。

我们发现最终渲染的图像中物体还是没有投射阴影,但通过帧调试器可以看到场景中的深度数据已经被渲染到阴影图集中了,可以把阴影距离先调小一点,这样可以使阴影图集中的图像尺寸占比大一点,方便观察。我们的阴影投射使用的是正交投影渲染,因为我们是为方向光进行渲染的。

4.1.7 支持多光源渲染阴影
之前我们设置最多可以支持4个方向光的照明,现在也支持4个方向光的阴影,在Shadows脚本中将最大投影灯光数量改为4。
const int maxShadowedDirectionalLightCount = 4;
然后在场景中复制3个方向光,在Y轴的旋转上依次增加90度。

1. 尽管我们正常渲染了所有光源的阴影投射,但我们为每盏灯渲染到整个阴影图集时,它们会叠加到一起,所以需要拆分图集,为每个光源使用自己的阴影贴图图块来渲染。我们可以支持4个灯光的投影,在图集里为每一个灯光分配一个正方形图块区域。如果有一个以上的可投影光源,需要将图块大小减半并把图集分成4个图块,在Shadows.RenderDirectionalShadows()方法中确定需要分割几个图块和图块大小应是多少,并传递给该方法的重载方法。
void RenderDirectionalShadows ()
{
…
//要分割的图块大小和数量
int split = ShadowedDirectionalLightCount <= 1 ? 1 : 2;
int tileSize = atlasSize / split;
for (int i = 0; i < ShadowedDirectionalLightCount; i++)
{
RenderDirectionalShadows(i, split, tileSize);
}
}
void RenderDirectionalShadows (int index, int split, int tileSize)
{
…
}
2. 我们通过调整渲染视口来渲染单个图块。用创建SetTileViewport方法,参数是图块的索引和拆分的图块数量,以及图块大小。我们计算该图块的偏移量,以图块索引%拆分数量为X轴偏移,索引/拆分数量为Y轴偏移。然后通过命令缓冲区调用SetViewPort方法,偏移量根据图块大小进行缩放,然后以图块大小作为该视口矩形的宽和高。
//调整渲染视口来渲染单个图块
void SetTileViewport(int index, int split,float tileSize)
{
//计算索引图块的偏移位置
Vector2 offset = new Vector2(index % split, index / split);
//设置渲染视口,拆分成多个图块
buffer.SetViewport(new Rect( offset.x * tileSize, offset.y * tileSize, tileSize, tileSize ));
}
3. 在RenderDirectionalShadows(int index, int split, int tileSize)方法中设置视图投影矩阵之前调整渲染视口。
//设置渲染视口
SetTileViewport(index, split, tileSize);
//设置视图投影矩阵
buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);

现在我们已经渲染了阴影贴图,但还需要对贴图进行采样才能接收物体的投影,采样操作需要在Shader的CustomLit Pass中完成。
4.2.1 阴影转换矩阵
1. 对于每个片元,我们必须从阴影图集中对应的阴影图块采样深度数据,因此需要找到对应在世界空间的阴影纹理坐标,可以通过为每个可投影的定向光创建阴影转换矩阵并将其发送到GPU来实现这点。首先在Shadows脚本中定义该阴影转换矩阵的着色器标识ID和一个存储转换矩阵的数组。
static int dirShadowMatricesId = Shader.PropertyToID("_DirectionalShadowMatrices");
//存储阴影转换矩阵
static Matrix4x4[] dirShadowMatrices = new Matrix4x4[maxShadowedDirectionalLightCount];
2. 在RenderDirectionalShadows方法中通过将获得的光源的投影矩阵和视图矩阵相乘,可以创建一个从世界空间到灯光空间的转换矩阵。
void RenderDirectionalShadows (int index, int split, int tileSize)
{
…
SetTileViewport(index, split, tileSize);
//投影矩阵乘以视图矩阵,得到从世界空间到灯光空间的转换矩阵
dirShadowMatrices[index] = projectionMatrix * viewMatrix;
buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);
…
}
3. 当渲染完阴影后,调用buffer.SetGlobalMatrixArray方法将转换矩阵发送到GPU。
void RenderDirectionalShadows ()
{
…
buffer.SetGlobalMatrixArray(dirShadowMatricesId, dirShadowMatrices);
buffer.EndSample(bufferName);
ExecuteBuffer();
}
4. 因为我们使用的是阴影图集,它可能包含多个阴影贴图图块,所以需要创建一个ConvertToAtlasMatrix方法来传入世界空间到灯光空间的转换矩阵、图块偏移和图块拆分数,最终得到一个从世界空间到阴影图块空间的转换矩阵。
//返回一个从世界空间到阴影图块空间的转换矩阵
Matrix4x4 ConvertToAtlasMatrix(Matrix4x4 m, Vector2 offset, int split)
{
return m;
}
5. 我们在SetTileViewport方法中已经计算了图块偏移,所以添加一个返回值直接拿到它。
Vector2 SetTileViewport (int index, int split, float tileSize)
{
…
return offset;
}
6. 调整RenderDirectionalShadows方法中阴影转换矩阵的获取,调用ConvertToAtlasMatrix方法来得到从世界空间到阴影纹理图块空间的转换矩阵。
//dirShadowMatrices[index] = projectionMatrix * viewMatrix;
dirShadowMatrices[index] = ConvertToAtlasMatrix(projectionMatrix * viewMatrix,
SetTileViewport(index, split, tileSize), split);
7. 最后实现ConvertToAtlasMatrix方法的具体内容,首先判断当前平台图形API,如果使用了反向Z-Buffer,就将矩阵的Z分量的值进行反转。(OpenGL中0是0深度,1是最大深度。其它图形API,如DirectX中0是0深度,-1是最大深度。由于深度缓冲精度受限(8、16、24bit),能表示的深度数量也有限,而通过反转能更好利用这些位,其它图形API如DirectX是使用了反向深度缓冲的)。
然后在立方体内定义裁剪空间,坐标是从-1到1,中心点是0。但是深度和纹理坐标是从0到1。要将此转换通过,需要把XYZ的尺寸进行缩放和偏移一半的方式拷贝到矩阵中,我们可以利用矩阵乘法做到这一点,但是会导致大量和0之间的乘法以及不必要的加法,所以我们直接调整矩阵。
//返回一个从世界空间转到阴影纹理图块空间的矩阵
Matrix4x4 ConvertToAtlasMatrix(Matrix4x4 m, Vector2 offset, int split)
{
//如果使用了反向Zbuffer
if (SystemInfo.usesReversedZBuffer)
{
m.m20 = -m.m20;
m.m21 = -m.m21;
m.m22 = -m.m22;
m.m23 = -m.m23;
}
//设置矩阵坐标
m.m00 = 0.5f * (m.m00 + m.m30);
m.m01 = 0.5f * (m.m01 + m.m31);
m.m02 = 0.5f * (m.m02 + m.m32);
m.m03 = 0.5f * (m.m03 + m.m33);
m.m10 = 0.5f * (m.m10 + m.m30);
m.m11 = 0.5f * (m.m11 + m.m31);
m.m12 = 0.5f * (m.m12 + m.m32);
m.m13 = 0.5f * (m.m13 + m.m33);
m.m20 = 0.5f * (m.m20 + m.m30);
m.m21 = 0.5f * (m.m21 + m.m31);
m.m22 = 0.5f * (m.m22 + m.m32);
m.m23 = 0.5f * (m.m23 + m.m33);
return m;
}
8. 最后把图块的偏移和缩放也算进去。
//设置矩阵坐标
float scale = 1f / split;
m.m00 = (0.5f * (m.m00 + m.m30) + offset.x * m.m30) * scale;
m.m01 = (0.5f * (m.m01 + m.m31) + offset.x * m.m31) * scale;
m.m02 = (0.5f * (m.m02 + m.m32) + offset.x * m.m32) * scale;
m.m03 = (0.5f * (m.m03 + m.m33) + offset.x * m.m33) * scale;
m.m10 = (0.5f * (m.m10 + m.m30) + offset.y * m.m30) * scale;
m.m11 = (0.5f * (m.m11 + m.m31) + offset.y * m.m31) * scale;
m.m12 = (0.5f * (m.m12 + m.m32) + offset.y * m.m32) * scale;
m.m13 = (0.5f * (m.m13 + m.m33) + offset.y * m.m33) * scale;
m.m20 = 0.5f * (m.m20 + m.m30);
m.m21 = 0.5f * (m.m21 + m.m31);
m.m22 = 0.5f * (m.m22 + m.m32);
m.m23 = 0.5f * (m.m23 + m.m33);
4.2.2 获取方向光的阴影数据
1. 要对光源的阴影采样,需要知道它在阴影图集的图块索引,我们在用ReserveDirectionalShadows方法中存储阴影数据的时候返回阴影强度和阴影图块的偏移,放在Vector2结构里一块返回。如果光源没有阴影则返回零向量。
public Vector2 ReserveDirectionalShadows (…)
{
if (…)
{
ShadowedDirectionalLights[ShadowedDirectionalLightCount] = new ShadowedDirectionalLight {visibleLightIndex = visibleLightIndex};
//返回阴影强度和阴影图块的偏移
return new Vector2(light.shadowStrength, ShadowedDirectionalLightCount++);
}
return Vector2.zero;
}
2. 在Lighting脚本中定义一个向量数组,存储方向光的阴影数据,通过在SetupDirectionalLight方法中得到它们,再在SetupLights方法中将阴影数据传递到GPU。
static int dirLightShadowDataId = Shader.PropertyToID("_DirectionalLightShadowData");
//存储阴影数据
static Vector4[] dirLightShadowData = new Vector4[maxDirLightCount];
void SetupLights ()
{
...
buffer.SetGlobalVectorArray(dirLightShadowDataId, dirLightShadowData);
}
void SetupDirectionalLight(int index, ref VisibleLight visibleLight)
{
...
//存储阴影数据
dirLightShadowData[index] = shadows.ReserveDirectionalShadows(visibleLight.light, index);
}
3. 最后在Light文件中的_CustomLight缓冲区定义字段获取传递来的阴影数据。
CBUFFER_START(_CustomLight)
...
//阴影数据
float4
_DirectionalLightShadowData[MAX_DIRECTIONAL_LIGHT_COUNT];
CBUFFER_END
4.2.3 阴影图集采样
1. 我们在ShaderLibrary子文件夹下创建Shadows .hlsl文件专门用来对阴影图集进行采样。定义支持投影的最大光源数和阴影图集纹理以及它的采样器,因为图集不是常规的纹理,所以用TEXTURE2D_SHADOW宏进行定义。最后在_CustomShadows缓冲区定义阴影的转换矩阵。
//阴影采样
#ifndef CUSTOM_SHADOWS_INCLUDED
#define CUSTOM_SHADOWS_INCLUDED
#define MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT 4
//阴影图集
TEXTURE2D_SHADOW(_DirectionalShadowAtlas);
#define SHADOW_SAMPLER sampler_linear_clamp_compare
SAMPLER_CMP(SHADOW_SAMPLER);
CBUFFER_START(_CustomShadows)
//阴影转换矩阵
float4x4 _DirectionalShadowMatrices[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT];
CBUFFER_END
#endif
2. 在LitPass中Include该文件,放在Light之前。
#include "../ShaderLibrary/Surface.hlsl"
#include "../ShaderLibrary/Shadows.hlsl"
#include "../ShaderLibrary/Light.hlsl"
3. 在Shadows文件中定义一个结构体,用来存储方向光的阴影数据,包括阴影强度和在图集中的图块索引。
//阴影的数据信息
struct DirectionalShadowData
{
float strength;
int tileIndex;
};
4. 为了采样阴影,需要知道表面的位置,通过在Surface结构体中定义它,并在片元函数中获取它。
struct Surface
{
//表面位置
float3 position;
...
};
Surface surface;
surface.position = input.positionWS;
surface.normal = normalize(input.normalWS);
5. 在Shadows中定义SampleDirectionalShadowAtlas方法,通过SAMPLE_TEXTURE2D_SHADOW宏对阴影图集采样,它需要图集和采样器,以及阴影纹理空间中的表面位置。
//采样阴影图集
float SampleDirectionalShadowAtlas(float3 positionSTS)
{
return SAMPLE_TEXTURE2D_SHADOW(_DirectionalShadowAtlas, SHADOW_SAMPLER, positionSTS);
}
6. 然后定义GetDirectionalShadowAttenuation方法计算阴影衰减,它通过阴影数据中的图块索引找到对应的转换矩阵,通过转换矩阵将表面位置从世界空间转换到阴影纹理空间,然后对阴影图集进行采样。
//计算阴影衰减
float GetDirectionalShadowAttenuation(DirectionalShadowData data, Surface surfaceWS)
{
//通过阴影转换矩阵和表面位置得到在阴影纹理(图块)空间的位置,然后对图集进行采样
float3 positionSTS = mul(_DirectionalShadowMatrices[data.tileIndex],float4(surfaceWS.position, 1.0)).xyz;
float shadow = SampleDirectionalShadowAtlas(positionSTS);
return shadow;
}
7. 采样阴影图集的结果是根据有多少光到达表面来决定的,它是一个[0,1]区间的值,通常叫做阴影衰减因子。如果片元完全被阴影覆盖则为0,如果没有任何阴影遮挡则为1,之间的值表示片元被部分阴影遮挡。
还有一种情况,当灯光的阴影强度属性被降到0时,阴影衰减就不受阴影影响了,衰减值始终为1,所以最终的阴影衰减应该是阴影强度和采样图集得到的衰减因子进行插值得到的。我们调整GetDirectionalShadowAttenuation方法的返回值,且灯光的阴影强度为0时采样阴影图集没有意义,阴影衰减值始终为1。
//得到阴影的衰减
float GetDirectionalShadowAttenuation(DirectionalShadowData data, Surface surfaceWS)
{
if (data.strength <= 0.0)
{
return 1.0;
}
...
//最终阴影衰减值是阴影强度和衰减因子的插值
return lerp(1.0, shadow, data.strength);
}
4.2.4 灯光的阴影衰减
1. 在Light结构体中添加一个光源的阴影衰减属性。
//灯光的属性
struct Light
{
float3 color;
float3 direction;
float attenuation;
};
2. 在Light文件中定义GetDirectionalShadowData方法来获取CPU传递过来的方向光阴影数据。
//获取方向光的阴影数据
DirectionalShadowData GetDirectionalShadowData(int lightIndex)
{
DirectionalShadowData data;
data.strength = _DirectionalLightShadowData[lightIndex].x;
data.tileIndex = _DirectionalLightShadowData[lightIndex].y;
return data;
}
3. 给GetDirectionalLight方法添加一个表面信息的传参,然后得到光源的阴影数据并计算阴影衰减。
//获取目标索引方向光的属性
Light GetDirectionalLight (int index,Surface surfaceWS)
{
...
//得到阴影数据
DirectionalShadowData shadowData = GetDirectionalShadowData(index);
//得到阴影衰减
light.attenuation = GetDirectionalShadowAttenuation(shadowData, surfaceWS);
return light;
}
4. 在Lighting.GetLighting方法中将表面信息surfaceWS传递到GetDirectionalLight方法中。
//根据物体的表面信息和灯光属性获取最终光照结果
float3 GetLighting(Surface surfaceWS, BRDF brdf)
{
//可见光的光照结果进行累加得到最终光照结果
float3 color = 0.0;
for (int i = 0; i < GetDirectionalLightCount(); i++)
{
color += GetLighting(surfaceWS, brdf, GetDirectionalLight(i, surfaceWS));
}
return color;
}
5. 在IncomingLight方法中将灯光的阴影衰减添加到入射光强度的计算中。
//得到入射光的数据
float3 IncomingLight (Surface surface, Light light)
{
return saturate(dot(surface.normal, light.direction)* light.attenuation) * light.color;
}


虽然得到了阴影,但是阴影质量很差,不应被投影的表面最终被形成像素化带的自阴影伪影所覆盖,即使提高阴影图集的分辨率只能有所改善,但也不能消除。灯光能够渲染阴影到屏幕中只是实现阴影的第一步,本节后续内容会逐渐完善阴影的各种功能和改善阴影质量。
使用阴影贴图通常会有透视走样的问题。透视走样指的是阴影越靠近相机,其边缘的锯齿化越严重。因为阴影贴图的分辨率是固定的,同样大小的一个阴影所对应的阴影贴图中纹素大小也是固定的(由于阴影贴图使用正交投影,因此阴影贴图中的每个纹理像素都有固定的世界空间大小)。如果使用透视相机,其效果是近大远小,在渲染时,阴影越靠近摄像机,越容易出现多个片元从阴影贴图中的同一纹素进行采样的情况,这几个片元得到的是相同的阴影值,从而产生锯齿边。使用高分辨率的阴影贴图可以降低锯齿边,但渲染时会占用更多内存和带宽。
级联阴影贴图(Cascaded Shadow Map)就是解决这个问题的,可以更好的兼顾精度和性能问题,它将摄像机的视截体按一定比例分成若干层(Cascade),每个层级对应一个子视截体,每一层都单独计算相关的阴影贴图,在渲染大场景时就可以避免使用单张阴影贴图的各种缺点。Unity就是使用的这种技术,我们可以在工程的Project Setting->Quality->Shadows看到:

下图是级联阴影贴图的原理。

4.3.1 设置级联
目前我们使用的是单级联,覆盖了整个场景区域。Unity的阴影源码中可以为每个方向光支持最多4个级联,我们在自己的管线中也添加该功能。首先在ShadowSettings脚本的Directional结构体中定义一个级联数量滑块,每个级联都会覆盖一部分阴影区域,直到达到设置的阴影最大距离为止。我们定义3个参数来调节前三级级联的区域比例,最后一级会覆盖整个区域,所以不用自己调节。默认情况下级联数量设置为4,前三级级联比例为0.1,0.25和0.5。
//平行光的阴影属性
[System.Serializable]
public struct Directional
{
public TextureSize atlasSize;
//级联数量
[Range(1, 4)]
public int cascadeCount;
//级联比例
[Range(0f, 1f)]
public float cascadeRatio1, cascadeRatio2, cascadeRatio3;
}
//图集大小默认为1024
public Directional directional = new Directional
{
atlasSize = TextureSize._1024,
cascadeCount = 4,
cascadeRatio1 = 0.1f,
cascadeRatio2 = 0.25f,
cascadeRatio3 = 0.5f
};

后续我们要将每级级联的比例传递到之前调用的ComputeDirectionalShadowMatricesAndCullingPrimitives方法中,它需要我们将三个比例值封装到一个Vector3向量中,我们来定义一个向量字段进行封装。
public Vector3 CascadeRatios => new Vector3(cascadeRatio1, cascadeRatio2, cascadeRatio3);
4.3.2 渲染级联
1. 在Shadows脚本中定义字段设置最大级联数量,因为每个级联都需要自己的阴影转换矩阵,所以转换矩阵的数组的大小应是方向光数量乘以最大级联数量。
//最大级联数量
const int maxCascades = 4;
//光源的阴影转换矩阵
static Matrix4x4[] dirShadowMatrices = new Matrix4x4[maxShadowedDirectionalLightCount * maxCascades];
2. 在Shadows.hlsl中也定义一个代表级联最大数量的宏和扩张数组大小。然后需要重启下Unity,因为在同一会话中的着色器中修改固定长度数组的大小是不生效的。
#define MAX_CASCADE_COUNT 4
CBUFFER_START(_CustomShadows)
//阴影矩阵
float4x4 _DirectionalShadowMatrices[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT * MAX_CASCADE_COUNT];
CBUFFER_END
3. 修改ReserveDirectionalShadows方法中阴影图块的索引,需要乘以级联数量,因为现在每个方向光声明多个连续的图块。
//返回阴影强度和阴影图块的索引
return new Vector2(light.shadowStrength, settings.directional.cascadeCount * ShadowedDirectionalLightCount++);
4. 现在整个阴影图集可以分割成最多4X4=16个图块,因此调整RenderDirectionalShadows方法中的分割图块数量和图块大小。
//要分割的图块数量和大小
int tiles = ShadowedDirectionalLightCount * settings.directional.cascadeCount;
int split = tiles <= 1 ? 1 : tiles <= 4 ? 2 : 4;
int tileSize = atlasSize / split;
5. 在RenderDirectionalShadows方法中,要为每个级联渲染阴影,我们在最外面添加一个for循环遍历级联数量,在循环中去调用ComputeDirectionalShadowMatricesAndCullingPrimitives()方法。之前我们说这9个传参中,第2、3、4个参数是级联阴影贴图的配置,当时我们使用的是默认参数,现在我们使用正式的值。第2个参数是当前级联的索引,第3个参数是级联的数量,第4个参数是Vector3类型的各级联比例,我们从阴影配置中拿到它们并设置。最后我们还需要调整图块索引,它等于光源的图块偏移加上级联的索引。
//渲染方向光阴影
void RenderDirectionalShadows(int index, int split, int tileSize)
{
...
//得到级联阴影贴图需要的参数
int cascadeCount = settings.directional.cascadeCount;
int tileOffset = index * cascadeCount;
Vector3 ratios = settings.directional.CascadeRatios;
for (int i=0;i<cascadeCount;i++)
{
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(light.visibleLightIndex, i, cascadeCount,ratios, tileSize, 0f,
out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix, out ShadowSplitData splitData);
shadowSettings.splitData = splitData;
//调整图块索引,它等于光源的图块偏移加上级联的索引
int tileIndex = tileOffset + i;
dirShadowMatrices[tileIndex] = ConvertToAtlasMatrix(projectionMatrix * viewMatrix,SetTileViewport(tileIndex, split, tileSize), split);
...
}
}
我们使用一个方向光源,将最大阴影距离设置为30,级联比例为0.3,0.4和0.5。通过帧调试器可以查看阴影图集被分割为了4个图块。

下图是带有4个方向光源的阴影图集。

4.3.3 级联包围球
要使用级联阴影,首先要为每个层级对应的子视截体构造一个投影矩阵,构建投影矩阵时,必须是在生成的阴影贴图中,并尽可能减少当前不在视野内的无关区域,也就是说要计算出和当前层级所对应的子视截体尽可能重合的投影矩阵,投影矩阵一般用正交投影,是一个能包住子视截体的且与光源空间坐标系轴对齐的包围盒(AABB)所对应生成的,如下图:

但因为在渲染时,摄像机的位置朝向等属性会及时改变,所以每个层级的子视截体都会不断变换,子视截体的轴对齐包围盒也要跟着变化,但这样可能导致出现前后两帧轴对齐包围盒发生突变,进而导致生成的阴影贴图的有效分辨率可能在连续的两帧中发生突变,产生阴影抖动问题,解决方案是把包围盒改为包围球,包围球随着子视截体的变化而发生大小的变化程度相对于包围盒来说小很多,如下图:

虽然阴影的投影可以契合包围球,但是球体还会覆盖周围的一些空间,导致我们在剔除区域之外也可以看到一些阴影。光的方向和球无关,所以我们所有的方向光都使用相同的包围球。
1. 我们需要知道这些球体应该从哪个级联中采样,因此需要将包围球数据发送到GPU,在Shadows脚本中定义级联包围球和级联数量的着色器标识ID,并定义一个Vector4类型的数组存储包围球数据,其中XYZ分量存储包围球的位置,W分量存储球体半径。
static int cascadeCountId = Shader.PropertyToID("_CascadeCount");
static int cascadeCullingSpheresId = Shader.PropertyToID("_CascadeCullingSpheres");
static Vector4[] cascadeCullingSpheres = new Vector4[maxCascades];
2. 级联包围球是从ComputeDirectionalShadowMatricesAndCullingPrimitives方法中得到的spitData数据的一部分,我们可以直接调用获取到,然后赋值给包围球数组。因为我们想要所有的光源都使用相同的级联,所以只需要拿到第一个方向光的包围球数据即可。
for (int i=0;i<cascadeCount;i++)
{
//计算视图和投影矩阵和裁剪空间的立方体
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(light.visibleLightIndex, i, cascadeCount,ratios, tileSize, 0f,
out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix, out ShadowSplitData splitData);
//得到第一个光源的包围球数据
if (index == 0)
{
cascadeCullingSpheres[i] = splitData.cullingSphere;
}
3. 我们后续需要在着色器中判断物体表面的片元是否在包围球中,可以通过该片元到球心距离的平方和球体半径的平方来比较,我们传递数据之前先计算好球体半径的平方,就不用再在着色器中计算了。
Vector4 cullingSphere = splitData.cullingSphere;
//得到半径的平方值
cullingSphere.w *= cullingSphere.w;
cascadeCullingSpheres[i] = cullingSphere;
4. 渲染级联后在RenderDirectionalShadows方法中将级联数量和级联包围球数据发送到GPU。
//将级联数量和包围球数据发送到GPU
buffer.SetGlobalInt(cascadeCountId, settings.directional.cascadeCount);
buffer.SetGlobalVectorArray(cascadeCullingSpheresId, cascadeCullingSpheres);
//阴影转换矩阵传入GPU
buffer.SetGlobalMatrixArray(dirShadowMatricesId, dirShadowMatrices);
buffer.EndSample(bufferName);
ExecuteBuffer();
4.3.4 采样级联
1. 在Shadows文件的_CustomShadows缓冲区中定义级联数量和包围球数据数组。
CBUFFER_START(_CustomShadows)
//级联数量和包围球数据
int _CascadeCount;
float4 _CascadeCullingSpheres[MAX_CASCADE_COUNT];
//阴影矩阵
float4x4 _DirectionalShadowMatrices[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT * MAX_CASCADE_COUNT];
CBUFFER_END
2. 定义一个结构体ShadowData存储表面阴影数据,在其中定义级联索引的属性,再定义一个GetShadowData方法,返回世界空间的表面阴影数据。最初只把级联索引设为0。
//阴影数据
struct ShadowData
{
int cascadeIndex;
};
//得到世界空间的表面阴影数据
ShadowData GetShadowData (Surface surfaceWS)
{
ShadowData data;
data.cascadeIndex = 0;
return data;
}
3. 将阴影数据添加到Light文件的GetDirectionalShadowData方法中,将级联索引和光源的阴影图块索引相加得到最终的图块索引。
//获取平行光阴影数据
DirectionalShadowData GetDirectionalShadowData(int lightIndex, ShadowData shadowData)
{
...
data.tileIndex = _DirectionalLightShadowData[lightIndex].y + shadowData.cascadeIndex;
return data;
}
4. 在GetDirectionalLight方法中也添加shadowData参数,把阴影数据发送到GetDirectionalShadowData方法中。
//获取目标索引平行光的属性
Light GetDirectionalLight (int index,Surface surfaceWS, ShadowData shadowData)
{
...
//得到阴影数据
DirectionalShadowData dirShadowData = GetDirectionalShadowData(index,shadowData);
//得到阴影衰减
light.attenuation = GetDirectionalShadowAttenuation(dirShadowData, surfaceWS);
return light;
}
5. 在Lighting.hlsl的GetLighting方法中获取表面阴影数据并传递到GetDirectionalLight方法中。
//根据物体的表面信息和灯光属性获取最终光照结果
float3 GetLighting(Surface surfaceWS, BRDF brdf)
{
//得到表面阴影数据
ShadowData shadowData = GetShadowData(surfaceWS);
//可见光的光照结果进行累加得到最终光照结果
float3 color = 0.0;
for (int i = 0; i < GetDirectionalLightCount(); i++)
{
Light light = GetDirectionalLight(i, surfaceWS, shadowData);
color += GetLighting(surfaceWS, brdf, light);
}
return color;
}
6. 要选择正确的级联,需要计算两点间距离的平方,在Common.hlsl中定义这么一个方法。
//计算两点间距离的平方
float DistanceSquared(float3 pA, float3 pB)
{
return dot(pA - pB, pA - pB);
}
7. 调整Shadows.hlsl文件中的GetShadowData方法得到合适的级联索引。循环所有级联包围球,如果物体表面到球心的距离的平方小于球体半径的平方,就说明该物体应在这层级联的包围球中,则跳出循环并得到正确的级联索引。
//得到世界空间的表面阴影数据
ShadowData GetShadowData (Surface surfaceWS)
{
ShadowData data;
int i;
//如果物体表面到球心的平方距离小于球体半径的平方,就说明该物体在这层级联包围球中,得到合适的级联层级索引
for (i = 0; i < _CascadeCount; i++)
{
float4 sphere = _CascadeCullingSpheres[i];
float distanceSqr = DistanceSquared(surfaceWS.position, sphere.xyz);
if (distanceSqr < sphere.w)
{
break;
}
}
data.cascadeIndex = i;
return data;
}

由于存在自阴影的伪影,导致级联之间的弯曲过渡边界也清晰可见。如果想试试看效果,可以在Light.hlsl文件的GetDirectionalLight()方法中用级联索引除以4来代替阴影衰减,这样效果就会比较明显,如下图所示:

4.3.5 剔除阴影采样
1. 如果我们最终超过了最后一个级联的范围,很可能没有有效阴影数据,这种情况下也不需要采样阴影了,没有意义。我们在Shadows.hlsl文件的ShadowData结构体中添加一个字段Strength作为一个标识符,如果超出最后一个级联范围就设为0。
struct ShadowData
{
int cascadeIndex;
//是否采样阴影的标识
float strength;
};
//得到世界空间的表面阴影数据
ShadowData GetShadowData (Surface surfaceWS)
{
ShadowData data;
data.strength = 1.0;
...
//如果超出最后一个级联的范围,标识符设置为0,不对阴影进行采样
if (i == _CascadeCount)
{
data.strength = 0.0;
}
data.cascadeIndex = i;
return data;
}
2. 在Light.hlsl文件的GetDirectionalShadowData方法中获取阴影强度时乘上这个Strength参数,这样可以剔除掉最后一个级联范围外的所有阴影。然后恢复GetDirectionalLight方法中的阴影衰减的计算,把级联索引除以4的测试代码删掉。
//获取平行光阴影数据
DirectionalShadowData GetDirectionalShadowData(int lightIndex, ShadowData shadowData)
{
DirectionalShadowData data;
data.strength = _DirectionalLightShadowData[lightIndex].x * shadowData.strength;
...
}
这样不在级联中的阴影就被剔除了,可以把阴影的最大距离调小一点观察一下。

根据上图我们会发现,有些物体的投影在最后一个级联的包围球内没有。原因就是最外面的包围球超出了我们设置的最大阴影距离,上图中我设置的最大距离是13,结果出现了这种问题。我们需要把最大阴影距离传到GPU,然后进行判断,如果超出最大距离就停止采样阴影来解决它。
3. 首先在Shadows脚本中定义阴影最大距离的着色器标识ID,通过RenderDirectionalShadows方法将最大阴影距离传到GPU。
static int shadowDistanceId = Shader.PropertyToID("_ShadowDistance");
void RenderDirectionalShadows ()
{
...
buffer.SetGlobalFloat(shadowDistanceId, settings.maxDistance);
buffer.EndSample(bufferName);
ExecuteBuffer();
}
4. 阴影最大距离基于的是视图空间的深度,而不是与相机的距离,为了进行剔除我们需要知道物体表面的深度,在Surface结构体重定义一个depth字段存储表面深度。
struct Surface
{
...
//表面深度
float depth;
};
5. 在片元函数中,调用源码库中的TransformWorldToView()方法把世界坐标转换到视图空间,并获取负的z坐标作为表面深度。由于这种转换只是相对于世界空间的旋转和偏移,因此在视图空间和世界空间中的深度都是相同的。
//得到视角方向
surface.viewDirection = normalize(_WorldSpaceCameraPos - input.positionWS);
//获取表面深度
surface.depth = -TransformWorldToView(input.positionWS).z;
6. 在Shadows.hlsl文件的_CustomShadows缓冲区中定义_ShadowDistance字段获取阴影最大距离,然后在GetShadowData方法中进行判断,当表面深度比最大阴影距离小时,才进行阴影采样。
CBUFFER_START(_CustomShadows)
...
float _ShadowDistance;
CBUFFER_END
ShadowData GetShadowData (Surface surfaceWS)
{
ShadowData data;
data.strength = surfaceWS.depth < _ShadowDistance ? 1.0 : 0.0;
…
}

4.3.6 阴影过渡
上图中还有一个问题,突然切断阴影最大距离处的阴影会显得很突兀,我们通过一种线性淡化的方式使阴影过渡变得柔和自然一些。阴影淡化应从阴影最大距离之前的一段距离开始,直到最大距离时阴影强度为0。我们使用下面的数学公式:

这是f分别为0.1,0.2和0.5时的示意图。

公式中d是表面的深度,m是阴影最大距离,f是阴影过渡范围,指的是到达阴影最大距离之前的那一小段距离。公式的最终结果应限制在[0,1]之间,下面进行具体实现。
1. 在ShadowSettings脚本中添加一个字段表示阴影过渡距离,默认为0.1。因为这个过渡距离和阴影最大距离在上面的数学公式中有一个相除的计算,所以该值不能为0,我们限制一下该字段的最小值为0.001。
//阴影最大距离
[Min(0.001f)]
public float maxDistance = 100f;
//阴影过渡距离
[Range(0.001f, 1f)]
public float distanceFade = 0.1f;
2. 在Shadows脚本中,定义一个阴影过渡距离着色器标识ID,代替原来的阴影最大距离标识ID。然后把阴影最大距离和阴影过渡距离的倒数传递给GPU,因为在着色器中乘法比除法效率更高。
//static int shadowDistanceId = Shader.PropertyToID("_ShadowDistance");
static int shadowDistanceFadeId = Shader.PropertyToID("_ShadowDistanceFade");
void RenderDirectionalShadows()
{
...
//最大阴影距离和阴影过渡距离发送GPU
//buffer.SetGlobalFloat(shadowDistanceId, settings.maxDistance);
buffer.SetGlobalVector(shadowDistanceFadeId,new Vector4(1f / settings.maxDistance, 1f / settings.distanceFade));
buffer.EndSample(bufferName);
ExecuteBuffer();
}
3. 在Shadows文件的_CustomShadows缓冲区中将阴影最大距离替换成新的字段。
CBUFFER_START(_CustomShadows)
...
//float _ShadowDistance;
//阴影过渡距离
float4 _ShadowDistanceFade;
CBUFFER_END
4. 最后定义一个FadedShadowStrength方法来计算阴影过渡时的强度,通过套用上面的数学公式完成。在GetShadowData方法中调用该方法得到有线性过渡的阴影强度。
//公式计算阴影过渡时的强度
float FadedShadowStrength (float distance, float scale, float fade)
{
return saturate((1.0 - distance * scale) * fade);
}
ShadowData GetShadowData (Surface surfaceWS)
{
ShadowData data;
//通过公式得到有线性过渡的阴影强度
data.strength =FadedShadowStrength(surfaceWS.depth, _ShadowDistanceFade.x, _ShadowDistanceFade.y);
...
}

4.3.7 级联过渡
我们也可以使用和阴影过渡相同的方法,在最后一个级联边缘对阴影进行平滑过渡,而不是硬切。在ShadowSettings脚本中定义一个级联阴影过渡的字段,并设置一个初值0.1。
public struct Directional
{
...
//级联淡入值
[Range(0.001f, 1f)]
public float cascadeFade;
}
public Directional directional = new Directional
{
…
cascadeRatio3 = 0.5f,
cascadeFade = 0.1f
};
和之前的过渡公式的区别是分子使用级联距离的平方除以包围球半径的平方,而不是之前线性的深度除以阴影最大距离,这意味着该阴影过渡是非线性的。

其中r是包围球的半径,我们要保持配置的过渡距离不变,需要将f替换成 1−(1−f)2,然后将该值存储在阴影过渡向量中的Z分量中并取反,一同发送到GPU。
float f = 1f - settings.directional.cascadeFade;
buffer.SetGlobalVector(shadowDistanceFadeId,new Vector4(1f / settings.maxDistance, 1f / settings.distanceFade,1f / (1f - f * f)));
在Shadow.hlsl文件的GetShadowData方法中判断要渲染的对象是否在最后一个级联的范围中,如果是则计算级联的过渡阴影强度,和阴影最大距离的过渡阴影强度相乘得到最终阴影强度。
if (distanceSqr < sphere.w)
{
//如果绘制的对象在最后一个级联的范围中,计算级联的过渡阴影强度,和阴影最大距离的过渡阴影强度相乘得到最终阴影强度
if (i == _CascadeCount - 1)
{
data.strength *= FadedShadowStrength(distanceSqr, 1.0 / sphere.w, _ShadowDistanceFade.z);
}
break;
}


现在我们已经实现了级联阴影,接下来要做的是提升阴影的质量,使用阴影贴图技术实现阴影的时候,如果不对阴影效果进行微调,就会出现这种交错条纹状阴影的情况,这种现象被比喻为“痤疮(Acne)”,专业术语叫做阴影渗漏(Shadow Acne)。
产生阴影渗漏的主因是阴影贴图分辨率的问题,如果分辨率比较小,导致在场景中多个片元在计算阴影时对应上了同一个阴影贴图的纹素,因而导致判断该片元到底在不在光线可到达的片元之前或者之后出现了问题。
如下图所示,片元A、B、C、D都对应于一个阴影贴图中的采样判定点P,而La、Lb、Lc、Ld分别对应于光源到片元A、B、C、D的距离,L对应于光源到采样判定点P的距离。

因为阴影贴图分辨率太小,导致A、B、C、D这4个在光源空间处于不同位置坐标的片元对应同一个阴影贴图的位置P上,并且P所对应的深度值为L,即光源到这一被照亮的位置点距离为L。如果点P所对应的片元位置与光源的距离小于L,该片元会被照亮,大于L就会遮住。
上图因为4个片元都没有被其它物体遮住,所以La、Lb、Lc、Ld长度无论是多少,都应该能被光源照亮。但实际计算中,因为阴影贴图分辨率太小,4个片元都只能使用L作为判断能否被照亮的距离,最终La<L,Lc<L,片元A和C被照亮,Lb>L,Ld>L,片元B和D被遮挡,于是导致了交错的条纹状阴影。
增大阴影贴图分辨率可以减小世界空间纹素大小,痤疮会变小,但不会消失,同时痤疮数量也会变多,所以无法通过修改阴影贴图分辨率来解决问题,下图我们使用的是8192的分辨率,可以验证这个问题。

4.4.1 调整深度偏差
解决阴影渗漏最直接的办法就是计算出La、Lb、Lc和Ld的长度,沿着这些线的反方向往回拉一拉,即减去一个微小的偏移值,使得最终La、Lb、Lc和Ld的长度都小于L,这样原本应该能被照亮的地方确实被照明了,这种方法叫做调整阴影偏差(Shadow Bias)。
1. 在Shadows脚本的RenderDirectionalShadows方法中,我们在渲染阴影前可以调用命令缓冲区的SetGlobalDepthBias方法来设置全局阴影深度偏差,我们设置一个较大的值50000试试看,该方法的第二个参数是斜度偏差,我们暂时设为0,绘制完阴影后全局深度偏差我们归零。
//设置深度偏差
buffer.SetGlobalDepthBias(50000f, 0f);
//绘制阴影
ExecuteBuffer();
context.DrawShadows(ref shadowSettings);
buffer.SetGlobalDepthBias(0f, 0f);

我们发现只消除了物体正面朝上被光照亮的表面的痤疮,去除所有痤疮需要一个更大的偏移值,我们把50000改成500000试试看。

调整阴影偏差有个问题,就是比较难定量的针对当前被照明的物体的表面凹凸程度设置准确的偏差值,随着深度偏差将阴影投射推离光线,采样阴影也会向同一方向移动。如果偏差设置的过小,依然会有一些应被照亮的片元没被照亮,如果偏移值过大就会导致影物飘离(Peter Panning),即原本某些应该被遮住不被照亮的片元反而被照亮,显得物体和它的影子分开了一样,如下图所示,图1是偏移值设置的过小的效果,图2是偏移值设置的过大的效果。


所以要找到一个刚好能消除阴影痤疮的值需要一定的技巧和算法。Unity中采用的阴影偏差值的计算方法是基于物体斜度(Slope)的,称为“基于斜度比例的深度偏差值”(Slope Scale Based Depth Bias)算法。大部分改善对阴影深度贴图采样误差的算法,其核心思想是分析待绘制场景中各部分内容对采样误差的影响程度。
在前面的代码中我们通过调用SetGlobalDepthBias方法设置全局深度偏差,第二个参数就是设置斜度偏差的,此值是该片元在水平或垂直方向上的导数值,对应正面照亮的物体表面,该值为0。当光线在水平或垂直方向至少其中一个以45度角入射时值为1。当表面法线和光照方向的点积是零时,该值为无穷大。因此需要更多时,偏差会自动增加且没有上限。我们试着将斜度偏差设置为3,第一个深度偏差参数设置为0来看看。
//设置斜度比例偏差
buffer.SetGlobalDepthBias(0, 3f);

斜度比例偏差效果还不错,但这只是个消除阴影痤疮的实验,而不是用影物飘离(Peter Panning)来替代阴影痤疮的消失,所以设置深度偏差的代码我们可以注释掉了,后面会讲解更好的替代方法消除阴影痤疮。
//设置深度偏差
//buffer.SetGlobalDepthBias(0, 3f);
//绘制阴影
ExecuteBuffer();
context.DrawShadows(ref shadowSettings);
//buffer.SetGlobalDepthBias(0f, 0f);
4.4.2 级联数据
1. 痤疮的大小跟世界空间的纹素大小有关,由于不同级联的纹素大小不一样,所以我们需要向GPU发送更多的级联数据,在Shadows脚本中定义级联数据的着色器标识ID和一个Vector4类型的数组存储级联数据,并在RenderDirectionalShadows方法中将该数据发送到GPU。
//级联数据
static int cascadeDataId = Shader.PropertyToID("_CascadeData");
static Vector4[] cascadeData = new Vector4[maxCascades];
void RenderDirectionalShadows()
{
//级联数据发送GPU
buffer.SetGlobalVectorArray(cascadeDataId, cascadeData);
//阴影转换矩阵传入GPU
buffer.SetGlobalMatrixArray(dirShadowMatricesId, dirShadowMatrices);
}
2. 我们把RenderDirectionalShadows方法中设置级联包围球数据的代码抽离出来,放在一个新定义的方法SetCascadeData中,之后还需将包围球半径的平方的倒数存储到级联数据的X分量中。
//得到第一个光源的级联包围球数据
if (index == 0)
{
//设置级联数据
SetCascadeData(i, splitData.cullingSphere, tileSize);
}
//设置级联数据
void SetCascadeData(int index, Vector4 cullingSphere, float tileSize)
{
cascadeData[index].x = 1f / cullingSphere.w;
//得到半径的平方值
cullingSphere.w *= cullingSphere.w;
cascadeCullingSpheres[index] = cullingSphere;
}
3. 在Shadows.hlsl文件的_CustomShadows缓冲区中定义级联数据字段,然后在GetShadowData方法中调用FadedShadowStrength时将第二个传参改为级联数据的X分量。
CBUFFER_START(_CustomShadows)
int _CascadeCount;
float4 _CascadeCullingSpheres[MAX_CASCADE_COUNT];
//级联数据
float4 _CascadeData[MAX_CASCADE_COUNT];
...
CBUFFER_END
if (i == _CascadeCount - 1)
{
data.strength *= FadedShadowStrength(distanceSqr, _CascadeData[i].x, _ShadowDistanceFade.z);
}
4.4.3 法线偏差
既然无法调整深度偏差来达到我们想要的效果,那我们尝试另外一个方法,即尝试在采样阴影时使表面沿法线方向偏移一点,然后对表面的一点进行采样,如果距离足够远就可以避免阴影痤疮,这虽然会让阴影的位置发生稍微的改变,可能导致边缘不对齐或添加假阴影,但这些改变远没有影物飘离(Peter Panning)来的明显。
接下来我们要沿表面的法线方向稍微移动表面的位置来采样阴影。如果只考虑一个维度,那么移动距离等于一个世界空间中纹素大小的偏移就足够了。
1. 在Shadows.hlsl文件的SetCascadeData方法中,通过包围球直径除以阴影图块尺寸得到纹素的大小,将结果存储在级联数据的Y分量中。需要注意的是,纹素是正方形,最坏的情况是不得不沿着正方形的对角线偏移,所以将纹素大小乘以根号2进行缩放。
//设置级联数据
void SetCascadeData(int index, Vector4 cullingSphere, float tileSize)
{
//包围球直径除以阴影图块尺寸=纹素大小
float texelSize = 2f * cullingSphere.w / tileSize;
//得到半径的平方值
cullingSphere.w *= cullingSphere.w;
cascadeCullingSpheres[index] = cullingSphere;
cascadeData[index] = new Vector4(1f / cullingSphere.w,texelSize * 1.4142136f);
}
2. 然后在Shadows.hlsl文件的GetDirectionalShadowAttenuation方法中添加一个ShadowData参数,在计算阴影纹理空间的位置之前,计算法线偏差,然后和表面顶点位置相加得到一个偏移后的新位置,将其通过转换矩阵转换到阴影纹理空间中,最后使用沿法线偏移后的新位置采样该阴影图集。
//得到阴影的衰减
float GetDirectionalShadowAttenuation(DirectionalShadowData directional, ShadowData global, Surface surfaceWS)
{
if (directional.strength <= 0.0)
{
return 1.0;
}
//计算法线偏差
float3 normalBias = surfaceWS.normal * _CascadeData[global.cascadeIndex].y;
//通过加上法线偏移后的表面顶点位置 得到在阴影纹理空间的新位置,然后对图集进行采样
float3 positionSTS = mul(_DirectionalShadowMatrices[directional.tileIndex],float4(surfaceWS.position+ normalBias, 1.0)).xyz;
float shadow = SampleDirectionalShadowAtlas(positionSTS);
return lerp(1.0, shadow, directional.strength);
}
3. 最后在Light.hlsl文件的GetDirectionalLight方法中调用GetDirectionalShadowAttenuation方法时传递shadowData参数。
//获取目标索引平行光的属性
Light GetDirectionalLight (int index,Surface surfaceWS, ShadowData shadowData)
{
...
//得到阴影衰减
light.attenuation = GetDirectionalShadowAttenuation(dirShadowData,shadowData, surfaceWS);
return light;
}

4.4.4 可配置的偏差属性
法线偏差基本已经很好地消除了阴影痤疮,但还会引入新的问题,如下图所示:

我们可以看到Cube紧挨地板的地方有一条不该存在的阴影线条,这是因为阴影从Cube的面上伸展出来,从而影响到了地面。我们可以通过设置斜度比例偏差来解决这个问题,但它没有一个固定且完美的值。
在光源组件上有一个Bias属性和Normal Bias属性,我们可以使用Bias属性作为我们管线的斜度比例偏差值来进行自由调节,然后将Normal Bias属性应用到我们计算好的法线偏差中,使得法线偏差也可以自由调节。
1. 在Shadows.cs脚本的ShadowedDirectionalLight结构体中添加一个斜度比例偏差字段。
struct ShadowedDirectionalLight
{
public int visibleLightIndex;
//斜度比例偏差值
public float slopeScaleBias;
}
2. 在ReserveDirectionalShadows方法中创建ShadowedDirectionalLight实例的时候,获取灯光上的阴影偏差属性,赋值给我们定义的斜度比例偏差字段。
ShadowedDirectionalLights[ShadowedDirectionalLightCount] = new ShadowedDirectionalLight{ visibleLightIndex = visibleLightIndex
,slopeScaleBias = light.shadowBias};
3. 在RenderDirectionalShadows方法中调用buffer.SetGlobalDepthBias方法配置斜度比例偏差。
//设置斜度比例偏差值
buffer.SetGlobalDepthBias(0, light.slopeScaleBias);
//绘制阴影
ExecuteBuffer();
context.DrawShadows(ref shadowSettings);
buffer.SetGlobalDepthBias(0f, 0f);
4. 灯光中还有一个Normal Bias属性可供我们调节法线偏差值,在ReserveDirectionalShadows方法中,我们将返回值由Vector2改为Vector3,第三个分量我们存储灯光的法线偏差。
public Vector3 ReserveDirectionalShadows (Light light, int visibleLightIndex)
{
if (...)
{
...
return new Vector3(light.shadowStrength,settings.directional.cascadeCount * ShadowedDirectionalLightCount++,light.shadowNormalBias);
}
return Vector3.zero;
}
5. 在Shadows.hlsl文件DirectionalShadowData结构体中定义一个法线偏差属性,然后在GetDirectionalShadowAttenuation方法中计算法线偏差时乘上灯光组件的法线偏差属性值。
struct DirectionalShadowData
{
float strength;
int tileIndex;
//法线偏差
float normalBias;
};
float GetDirectionalShadowAttenuation(DirectionalShadowData directional, ShadowData global, Surface surfaceWS)
{
...
//计算法线偏移
float3 normalBias = surfaceWS.normal * (directional.normalBias * _CascadeData[global.cascadeIndex].y);
...
}
6. 最后在Light.hlsl文件的GetDirectionalShadowData方法中获得CPU传递过来的灯光组件的法线偏差。
DirectionalShadowData GetDirectionalShadowData(int lightIndex, ShadowData shadowData)
{
...
//获取灯光的法线偏差值
data.normalBias = _DirectionalLightShadowData[lightIndex].z;
return data;
}

现在我们调整光源组件的两个偏差值,如果增大第一个偏差值,则你可以减小第二个偏差值,我们将法线偏差和斜度比例偏差值都设为0.6,发现阴影线条基本被消除了。需要注意的是,灯光组件的Bias和Normal Bias属性在我们修改用途之前代表的是裁剪空间的深度偏差和世界空间的法线偏差,所以当创建新的方向光时,如果不根据实际情况调整偏差,有可能会出现严重的影物飘离(Peter Panning)。
4.4.5 阴影平坠(Shadow Pancaking)
可能导致阴影痤疮的另一个潜在问题是如果在Unity中应用了阴影平坠(Shadow Pancaking)技术,那么就可以剔除那些不希望看到的阴影。该技术的想法是渲染方向光的阴影时,通过剪裁光照空间,给该空间设定阴影视椎体近裁剪平面,只有位于该平面内的物体才能投射阴影,且阴影视椎体近裁剪平面会尽可能的向前移动,意在减少沿光照方向渲染阴影贴图时使用的光照空间范围,这可以提高阴影贴图的精度,减少阴影痤疮。
如下图所示,其中浅蓝圆圈代表阴影投射物,深蓝矩形代表原始光照空间,绿线代表优化的阴影视椎体近裁剪平面(它排除了在视锥体中不可见的所有阴影投射物)。

这个办法虽然有效,但是对于穿过阴影视椎体近裁剪平面的大型三角形,会带来一些瑕疵。如下图所示,在这种情况下,只有蓝色三角形的一个顶点位于近裁剪面背后并被钳制到此处。但是,这会改变三角形的形状,并可能产生不正确的阴影。

Unity可以通过调整QualitySettings中的ShadowNearPlaneOffset属性避免发生这种问题,该属性用于调整阴影视椎体近裁剪平面的偏移。如果将此值设置得过高,则最终还是会引入阴影痤疮,因为它会提高阴影贴图需要在光照方向上覆盖的范围;如果该值调的太低,又会产生漏光。如上图一样,绿色的平面会被往上推很多,则原本该产生阴影的圆形将被部分裁剪而不再产生投影,造成了阴影镂空了一部分。
如果在Scene视图中将视野拉近,会发现一些投射的阴影中间有部分镂空,如下图所示。

1. 在ShadowCasterPass.hlsl文件中,我们可以在顶点函数中将顶点位置限制到阴影视椎体近裁剪平面中解决这个问题,把在近裁剪平面前面的阴影投射展平,让它们像黏在近裁剪平面上的花纹一样。我们可以通过得到顶点在裁剪空间的Z和W分量之间的最大值,或在当UNITY_REVERSED_Z宏被定义时的两者之间的最小值来做到这一点,要让W分量使用正确的符号,需要乘上UNITY_NEAR_CLIP_VALUE。
output.positionCS = TransformWorldToHClip(positionWS);
#if UNITY_REVERSED_Z
output.positionCS.z =
min(output.positionCS.z, output.positionCS.w * UNITY_NEAR_CLIP_VALUE);
#else
output.positionCS.z =
max(output.positionCS.z, output.positionCS.w * UNITY_NEAR_CLIP_VALUE);
#endif

这非常适用完全在近裁剪平面两侧的投射阴影,但可能对某些大三角形物体来说只影响了部分顶点,而与近裁剪平面有穿叉的投影则会变形,这对于那些小三角形来说,不是很明显。如下图所示,非常长的立方体阴影产生了变形:

2. 我们通过把阴影视椎体近裁剪平面向后拉一点来缓解该问题。在Shadows脚本的ShadowedDirectionalLight结构体定义一个近裁剪平面偏移字段,在ReserveDirectionalShadows方法中创建该实例的时候把灯光的阴影视椎体近裁剪平面偏移属性值赋值给它。
struct ShadowedDirectionalLight
{
...
//阴影视椎体近裁剪平面偏移
public float nearPlaneOffset;
}
ShadowedDirectionalLights[ShadowedDirectionalLightCount] = new ShadowedDirectionalLight{ visibleLightIndex = visibleLightIndex,
slopeScaleBias = light.shadowBias,
nearPlaneOffset = light.shadowNearPlane };
3. 在RenderDirectionalShadows方法的cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives()调用中,将第6个传参由0改为我们获取到的阴影视椎体近裁剪平面偏移值,然后我们就可以调节灯光组件上的Near Plane属性来调整效果了。
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(light.visibleLightIndex, i, cascadeCount,ratios, tileSize, light.nearPlaneOffset,
out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix, out ShadowSplitData splitData);

4.4.6 百分比切近滤波(PCF)
接下来该解决阴影的锯齿问题了。产生锯齿的原因在于,判断一个片元是否在阴影内而进行深度测试时,要把该片元从当前摄像机观察空间转换到光源空间,因为转换矩阵不一样,且阴影贴图分辨率不大,导致观察空间中多个片元对应阴影贴图同一个纹素。例如两个黑色锯齿中间的空白部分,本来这部分也应该处于黑色阴影中的,但因为采样到的阴影贴图中的纹素刚好不是黑色的,即那个纹素刚好不在黑色阴影下,就会导致阴影锯齿。
解决锯齿最直接的办法就是提高阴影贴图分辨率,但内存占用会变大,这种方法也只是减轻问题。实际开发中通常采用适中的分辨率的阴影贴图加上区域采样方法改善锯齿现象。
因为阴影贴图的纹素存储的不是颜色信息而是深度信息,对深度值取均值会产生不正确的深度结果,所以锯齿不能通过对某纹素周边邻接的纹素取值然后求平均来消除。百分比切近滤波(Percentage-Close Filtering,PCF)方法,是对阴影比较测试后的值进行滤波,可以使生成的阴影边缘平滑柔和。
PCF方法具体步骤是:在片元着色器中,把当前操作的片元f先变换到光源空间,然后经过投影和视口变换到阴影深度贴图空间中,假设变换后深度值为z,对应的贴图坐标为(u,v),该坐标对应的纹素深度值为z0。进行到这一步,如果不使用PCF方法,那么直接就根据z和z0的大小判断该片元在阴影中全黑还是不在阴影中不黑。PCF是对贴图坐标(u,v)处周边纹素也进行采样获取其深度值,再和当前片元的深度值z比较。如果在阴影中标识为1,不在阴影中标识为0,并把这些01值每项累加求平均值,这些平均值落在[0,1]中,这样阴影就有浓淡之分而不像未使用PCF方法之前的非明即暗,从而达到柔化边缘,减少锯齿的效果。
下图演示了PCF 3X3方法的采样效果:

1. 3X3的滤波模式代表我们对9个纹理像素进行深度比较,一般常用的有2X2、3X3、5X5和7X7,在ShadowSettings脚本中我们添加一个滤波模式枚举,将常用的模式添加进来,默认值设为2X2。
//PCF滤波模式
public enum FilterMode
{
PCF2x2, PCF3x3, PCF5x5, PCF7x7
}
public struct Directional
{
public MapSize atlasSize;
public FilterMode filter;
...
}
public Directional directional = new Directional
{
atlasSize = MapSize._1024,
filter = FilterMode.PCF2x2,
...
};

2. 在Shadows脚本中添加一个静态字符串数组,然后添加一个SetKeywords方法,设置使用哪个滤波模式的关键字,最后在RenderDirectionalShadows方法中执行缓冲区之前调用SetKeywords方法。
//PCF滤波模式
static string[] directionalFilterKeywords =
{
"_DIRECTIONAL_PCF3",
"_DIRECTIONAL_PCF5",
"_DIRECTIONAL_PCF7",
};
//设置关键字开启哪种PCF滤波模式
void SetKeywords()
{
int enabledIndex = (int)settings.directional.filter - 1;
for (int i = 0; i < directionalFilterKeywords.Length; i++)
{
if (i == enabledIndex)
{
buffer.EnableShaderKeyword(directionalFilterKeywords[i]);
}
else
{
buffer.DisableShaderKeyword(directionalFilterKeywords[i]);
}
}
}
void RenderDirectionalShadows ()
{
...
SetKeywords();
buffer.EndSample(bufferName);
ExecuteBuffer();
}
3. 较大的滤波模式需要更多的纹素样本,我们需要知道阴影的图集大小和纹素大小,添加一个阴影图集大小的着色器标识ID,然后在RenderDirectionalShadows方法中将图集大小和纹素大小传递给GPU。
static int shadowAtlasSizeId = Shader.PropertyToID("_ShadowAtlasSize");
void RenderDirectionalShadows()
{
...
//设置关键字
SetKeywords();
//传递图集大小和纹素大小
buffer.SetGlobalVector( shadowAtlasSizeId, new Vector4(atlasSize, 1f / atlasSize));
}
4. 在Shadows.hlsl的_CustomShadows缓冲区定义图集大小字段。
CBUFFER_START(_CustomShadows)
...
float4 _ShadowAtlasSize;
CBUFFER_END
5. 在Lit.shader的CustomLit Pass中添加这四个滤波模式关键字,_代表默认的2X2。
#pragma multi_compile _ _DIRECTIONAL_PCF3 _DIRECTIONAL_PCF5 _DIRECTIONAL_PCF7
#pragma multi_compile_instancing
6. 我们将要使用源码库ShadowSamplingTent.hlsl文件里定义的函数,先将该文件Include到Shadows.hlsl文件的顶部。如果定义了3X3的滤波模式的关键字,则需要4个过滤器样本,因为每个样本都使用双线性2X2的滤波模式,在所有方向上偏移半个纹素的平方覆盖了3×3的Tent Filter,其中心的权重大于边缘。然后使用SampleShadow_ComputeSamples_Tent_3x3函数设置这些样本,5X5需要9个滤波样本、7X7需要16个滤波样本。
#ifndef CUSTOM_SHADOWS_INCLUDED
#define CUSTOM_SHADOWS_INCLUDED
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Shadow/ShadowSamplingTent.hlsl"
//如果使用的是PCF 3X3
#if defined(_DIRECTIONAL_PCF3)
//需要4个滤波样本
#define DIRECTIONAL_FILTER_SAMPLES 4
#define DIRECTIONAL_FILTER_SETUP SampleShadow_ComputeSamples_Tent_3x3
#elif defined(_DIRECTIONAL_PCF5)
#define DIRECTIONAL_FILTER_SAMPLES 9
#define DIRECTIONAL_FILTER_SETUP SampleShadow_ComputeSamples_Tent_5x5
#elif defined(_DIRECTIONAL_PCF7)
#define DIRECTIONAL_FILTER_SAMPLES 16
#define DIRECTIONAL_FILTER_SETUP SampleShadow_ComputeSamples_Tent_7x7
#endif
7. 创建FilterDirectionalShadow方法,参数是阴影图块的空间位置。当定义了DIRECTIONAL_FILTER_SETUP关键字时,需要多次采样,否则只需调用一次SampleDirectionalShadowAtlas方法即可。
float FilterDirectionalShadow (float3 positionSTS)
{
#if defined(DIRECTIONAL_FILTER_SETUP)
float shadow = 0;
return shadow;
#else
return SampleDirectionalShadowAtlas(positionSTS);
#endif
}
8. DIRECTIONAL_FILTER_SETUP方法需要传递四个参数,第一个float4类型的向量,其中XY分量是图集纹素大小,ZW分量是图集尺寸,第二个参数是原始样本的位置,后两个是样本的权重和样本的位置。然后遍历所有滤波样本,将所有的样本权重进行累加。
float FilterDirectionalShadow(float3 positionSTS)
{
#if defined(DIRECTIONAL_FILTER_SETUP)
//样本权重
float weights[DIRECTIONAL_FILTER_SAMPLES];
//样本位置
float2 positions[DIRECTIONAL_FILTER_SAMPLES];
float4 size = _ShadowAtlasSize.yyxx;
DIRECTIONAL_FILTER_SETUP(size, positionSTS.xy, weights, positions);
float shadow = 0;
for (int i = 0; i < DIRECTIONAL_FILTER_SAMPLES; i++)
{
//遍历所有样本得到权重和
shadow += weights[i] * SampleDirectionalShadowAtlas(
float3(positions[i].xy, positionSTS.z)
);
}
return shadow;
#else
return SampleDirectionalShadowAtlas(positionSTS);
#endif
}
9. 最后在GetDirectionalShadowAttenuation方法中调用FilterDirectionalShadow方法得到经过调整后的阴影衰减。
//得到阴影的衰减
float GetDirectionalShadowAttenuation(DirectionalShadowData directional, ShadowData global, Surface surfaceWS)
{
...
float shadow = FilterDirectionalShadow(positionSTS);
//最终衰减结果是阴影强度和采样衰减的线性差值
return lerp(1.0, shadow, directional.strength);
}


10. 测试发现,使用大的滤波模式可以使阴影更平滑,锯齿变得不明显,但是阴影痤疮会再次出现。我们需要增加法线偏差来匹配对应滤波模式的尺寸,在Shadows脚本的SetCascadeData方法中通过将纹素大小乘以1加上SetCascadeData中的滤波模式枚举值来根据滤波模式调整法线偏差的大小。
另外增加样本区域意味着最终在最后一个级联的包围球范围之外也有可能进行采样,要在计算包围球半径的平方之前,使用包围球半径减去经过调整后的纹素大小(偏差大小)来避免这种情况。
void SetCascadeData (int index, Vector4 cullingSphere, float tileSize)
{
float texelSize = 2f * cullingSphere.w / tileSize;
float filterSize = texelSize * ((float)settings.directional.filter + 1f);
cullingSphere.w -= filterSize;
//得到半径的平方值
cullingSphere.w *= cullingSphere.w;
cascadeCullingSpheres[index] = cullingSphere;
cascadeData[index] = new Vector4(1f / cullingSphere.w, filterSize * 1.4142136f);
}

4.4.7 混合级联
现在的阴影已经比较柔和了,但是会使得各级联之间的过渡更加明显。可以通过在级联之间添加一个过渡区域来进行相邻级联之间的混合从而使级联过渡更柔和一些。
1. 在Shadows.hlsl文件的ShadowData结构体中添加一个混合级联的属性。后续使用它在相邻级联之间进行插值。
struct ShadowData
{
int cascadeIndex;
//是否采样阴影的标识
float strength;
//混合级联
float cascadeBlend;
};
2. 最初在GetShadowData方法中将级联混合属性设为1,表示所选的级联处于完全的强度。然后在循环中的对应级联被找到后,始终计算级联的阴影过渡强度。如果处在最后一个级联范围中,就像之前一样计算阴影强度,否则将阴影过渡强度赋值给级联混合属性。
ShadowData GetShadowData (Surface surfaceWS)
{
ShadowData data;
data.cascadeBlend = 1.0;
...
for (i = 0; i < _CascadeCount; i++)
{
float4 sphere = _CascadeCullingSpheres[i];
float distanceSqr = DistanceSquared(surfaceWS.position, sphere.xyz);
if (distanceSqr < sphere.w)
{
//计算级联阴影的过渡强度
float fade = FadedShadowStrength(distanceSqr, _CascadeData[i].x, _ShadowDistanceFade.z);
//如果对象处在最后一个级联范围中
if (i == _CascadeCount - 1)
{
data.strength *= fade;
}
else
{
data.cascadeBlend = fade;
}
break;
}
}
...
}
3. 在GetDirectionalShadowAttenuation方法中,得到当前级联的阴影衰减后检查级联混合属性是否小于1,小于1就代表对象现在在级联的过渡区域中,必须从下一个级联中进行采样阴影贴图并得到当前级联的阴影衰减,根据级联混合属性值对两个级联的阴影衰减强度进行插值。
//得到阴影的衰减
float GetDirectionalShadowAttenuation(DirectionalShadowData directional, ShadowData global, Surface surfaceWS)
{
...
float shadow = FilterDirectionalShadow(positionSTS);
//如果级联混合小于1代表在在级联层级过渡区域中,必须从下一个级联中采样并在两个值之间进行插值
if (global.cascadeBlend < 1.0)
{
normalBias = surfaceWS.normal *(directional.normalBias * _CascadeData[global.cascadeIndex + 1].y);
positionSTS = mul(_DirectionalShadowMatrices[directional.tileIndex + 1],float4(surfaceWS.position + normalBias, 1.0)).xyz;
shadow = lerp(FilterDirectionalShadow(positionSTS), shadow, global.cascadeBlend);
}
//最终衰减结果是阴影强度和采样衰减的线性差值
return lerp(1.0, shadow, directional.strength);
}
4.4.8 级联过渡抖动
虽然从级联混合效果上看起来不错,但这会使我们必须在级联混合区域中对阴影贴图多采样一次。有一种替代方法是基于抖动模式始终从一个级联中采样。虽然这看起来不太好,但是可以提升计算效率,也会减少一次采样,尤其在使用大的PCF过滤模式时。
1. 在ShadowSettings脚本中定义一个代表级联混合模式的枚举,支持Hard,Soft和 Dither三种模式,默认赋值Hard。
public enum CascadeBlendMode
{
Hard, Soft, Dither
}
public CascadeBlendMode cascadeBlend;
public Directional directional = new Directional
{
...
cascadeFade = 0.1f,
cascadeBlend = Directional.CascadeBlendMode.Hard
};
2. 在Shadows脚本中添加该级联混合模式关键字的静态字符串数组,调整SetKeywords方法,让它可以匹配传入的任何关键字的字符串数组和索引,然后在RenderDirectionalShadows方法中设置级联混合模式的关键字。
static string[] cascadeBlendKeywords = {"_CASCADE_BLEND_SOFT","_CASCADE_BLEND_DITHER"};
void SetKeywords(string[] keywords, int enabledIndex)
{
// int enabledIndex = (int)settings.directional.filter - 1;
for (int i = 0; i < keywords.Length; i++)
{
if (i == enabledIndex)
{
buffer.EnableShaderKeyword(keywords[i]);
}
else
{
buffer.DisableShaderKeyword(keywords[i]);
}
}
}
void RenderDirectionalShadows ()
{
...
SetKeywords(directionalFilterKeywords, (int)settings.directional.filter - 1);
SetKeywords(cascadeBlendKeywords, (int)settings.directional.cascadeBlend - 1);
buffer.SetGlobalVector(shadowAtlasSizeId, new Vector4(atlasSize, 1f / atlasSize));
buffer.EndSample(bufferName);
ExecuteBuffer();
}
3. 在Lit.shader的CustomLit Pass中添加这三种级联混合模式的关键字,_默认关键字代表Hard混合模式。
#pragma multi_compile _ _CASCADE_BLEND_SOFT _CASCADE_BLEND_DITHER
#pragma multi_compile_instancing
4. 在Surface结构体中添加一个抖动属性。
struct Surface
{
...
float dither;
};
5. 在片元函数中我们生成抖动值,最简单的办法是使用源码库中的InterleavedGradientNoise方法,传递屏幕空间顶点位置的XY分量,生成一个随机的抖动(噪声)值。第二个参数用于动画,我们不需要,所以设为0。
surface.smoothness =UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Smoothness);
//计算抖动值
surface.dither = InterleavedGradientNoise(input.positionCS.xy, 0);
6. 在Shadows.hlsl文件的GetShadowData方法中,在设置级联索引前,判断级联混合模式是否为Soft,如果是则将级联混合值设为1。当混合模式为抖动模式时,如果我们不在最后一个级联中,且当级联混合值小于抖动值时,则跳到下一个级联。
//如果超出级联范围,不进行阴影采样
if (i == _CascadeCount)
{
data.strength = 0.0;
}
#if defined(_CASCADE_BLEND_DITHER)
else if (data.cascadeBlend < surfaceWS.dither)
{
i += 1;
}
#endif
#if !defined(_CASCADE_BLEND_SOFT)
data.cascadeBlend = 1.0;
#endif
data.cascadeIndex = i;
return data;
4.4.9 剔除偏差
使用级联阴影贴图有一个缺点,我们不止一次对每个光源渲染相同的投影。如果大的级联中的一些投影数据能被小的级联中的投影数据覆盖,就可以从大的级联中剔除这些投影。在Shadows脚本的RenderDirectionalShadows方法中将splitData的shadowCascadeBlendCullingFactor属性设置为1来实现这点。在渲染方向光阴影之前执行这个操作。
//剔除偏差
splitData.shadowCascadeBlendCullingFactor = 1f;
shadowSettings.splitData = splitData;
该值是调节用于执行剔除的上一个级联的半径的因子。在剔除时,Unity相当的保守,但我们应该通过级联过渡比例降低它,确保过渡区域中的投影不会被剔除。在RenderDirectionalShadows方法中我们使用0.8减去级联过渡值,最小值限制到零。如果看到在级联过渡的阴影中出现孔洞,则必须进一步减少孔洞。
void RenderDirectionalShadows(int index, int split, int tileSize)
{
...
float cullingFactor = Mathf.Max(0f, 0.8f - settings.directional.cascadeFade);
for (int i = 0; i < cascadeCount; i++)
{
...
splitData.shadowCascadeBlendCullingFactor = cullingFactor;
...
}
}

现在裁切模式的物体的投影是正确的,但是透明物体的投影感觉跟不透明物体的投影效果是一样的。

4.5.1 裁剪阴影
1. 我们在Lit.shader的属性栏中添加一个调整投影模式的切换开关,可以选择开启、关闭、裁切和抖动阴影,默认投影阴影是开启状态。
//投影模式
[KeywordEnum(On, Clip, Dither, Off)] _Shadows ("Shadows", Float) = 0
2. 添加一组Shader关键字替换之前的_CLIPPING关键字,现在只需要三个变体,_代表投影的开和关。
//#pragma shader_feature _CLIPPING
#pragma shader_feature _ _SHADOWS_CLIP _SHADOWS_DITHER
3. 在CustomShaderGUI脚本中定义一个表示投影模式的枚举,然后添加一个枚举属性决定定义哪种投影模式的关键字。
enum ShadowMode
{
On, Clip, Dither, Off
}
ShadowMode Shadows
{
set
{
if (SetProperty("_Shadows", (float)value))
{
SetKeyword("_SHADOWS_CLIP", value == ShadowMode.Clip);
SetKeyword("_SHADOWS_DITHER", value == ShadowMode.Dither);
}
}
}
4. 还需在ShadowCasterPass.hlsl文件的片元函数中,将判断是否定义了_CLIPPING的关键字替换为_SHADOWS_CLIP。
#if defined(_SHADOWS_CLIP)
clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
#endif
下图是透明物体使用裁剪投影模式的效果。

现在透明物体可以裁剪阴影了,这可能适合物体表面部分大多是完全不透明或透明的,且透明度混合也是必要的。需要注意的是,裁剪的阴影是不稳定的,当视图移动时阴影转换矩阵会发生变化,会造成片元稍有移动,可能会导致阴影贴图的纹素突然从裁剪状态过渡到未裁剪状态。
4.5.2 抖动阴影
抖动和裁剪的条件是不同的,但作用相同,我们在片元函数中用表面的Alpha值减去抖动值来进行片元裁剪。
#if defined(_SHADOWS_CLIP)
//透明度低于阈值的片元进行舍弃
clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
#elif defined(_SHADOWS_DITHER)
float dither = InterleavedGradientNoise(input.positionCS.xy, 0);
clip(base.a - dither);
#endif
下图是透明物体使用抖动投影模式的效果。

抖动可以用于半透明物体的投影,但硬抖动阴影看起来很糟糕,不过在使用较大的PCF过滤模式时,它看起来效果还不错,下图是PCF 7X7模式下的效果。

由于抖动投影模式会固定每个纹素,重叠的半透明投影不会投射组合的较暗阴影,投影效果和不透明物体的投影一样强。此外,由于该模式的抖动值是一个噪声值,当阴影转换矩阵发生变化时,会使阴影看起来在抖。此模式适用于具有固定投影(投影对象不移动)的其它光源类型,对于半透明物体,使用裁剪投影模式或关闭投影通常更好一些。
4.5.3 不投影
我们可以通过禁用材质的ShadowCaster Pass来实现对每种材质投影的灵活禁用。在CustomShaderGUI脚本中添加SetShadowCasterPass方法,首先检查_Shadows着色器属性是否存在,且所有选定的材质是否设置为相同的混合模式。条件都满足后,遍历所有材质通过其SetShaderPassEnabled方法的调用来选择启用或禁用ShadowCaster Pass。
然后在OnGUI方法的最后检查是否有材质属性被更改,来重新检查和设置材质的ShadowCaster Pass的状态。
//设置材质的ShadowCaster pass是否启用
void SetShadowCasterPass()
{
MaterialProperty shadows = FindProperty("_Shadows", properties, false);
if (shadows == null || shadows.hasMixedValue)
{
return;
}
bool enabled = shadows.floatValue < (float)ShadowMode.Off;
foreach (Material m in materials)
{
m.SetShaderPassEnabled("ShadowCaster", enabled);
}
}
public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties)
{
EditorGUI.BeginChangeCheck();
...
if (EditorGUI.EndChangeCheck())
{
SetShadowCasterPass();
}
}

另外,我们在前面的小节中还编写了一个不受光的着色器Unlit.shader,如果想要该着色器支持投影,可以把Lit.shader中的ShaderCaster Pass拷贝过来,就可以投影了。

4.5.4 接受阴影
我们在Lit.shader的属性栏中定义一个是否接受投影的切换开关,默认为接受投影,并定义一个与其关联的关键字。
[Toggle(_RECEIVE_SHADOWS)] _ReceiveShadows ("Receive Shadows", Float) = 1
#pragma shader_feature _RECEIVE_SHADOWS
在Shadows.hlsl文件的GetDirectionalShadowAttenuation方法最前面判断是否接受阴影,如果不接受则阴影衰减直接返回1就可以了。
float GetDirectionalShadowAttenuation(DirectionalShadowData directional, ShadowData global, Surface surfaceWS)
{
//如果不接受阴影,阴影衰减为1
#if !defined(_RECEIVE_SHADOWS)
return 1.0;
#endif
...
}

4|方向阴影
提交
暂无评论